3.1. Diffuse IBL

LearnOpenGL - PBR - IBL - Diffuse irradiance

기록

적용 전
4. Archive/WhiteEngine/PBR/IBL/Attachments/Pasted image 20250320193340.png

적용 후
4. Archive/WhiteEngine/PBR/IBL/Attachments/Pasted image 20250324091607.png

Diffuse irradiance

IBL(Image-Based Lighting, 이미지 기반 조명)은 이전 장에서 다룬 직접적인 분석적 광원이 아니라, 주변 환경을 하나의 거대한 광원으로 취급하여 객체를 조명하는 일련의 기술이다. 일반적으로, 실제 환경에서 촬영한 큐브맵 환경 맵을 사용하거나 3D 장면에서 생성한 큐브맵을 변형하여, 이를 조명 방정식에 직접 활용하는 방식으로 이루어진다. 이는 큐브맵의 각 텍셀을 광원으로 간주하는 개념으로, 환경의 전역 조명과 분위기를 효과적으로 포착하여 객체가 해당 환경에 자연스럽게 어우러지는 느낌을 준다.

이미지 기반 조명 알고리즘은 특정 환경의 조명을 캡처하므로, 입력값이 보다 정밀한 형태의 주변광(Ambient Lighting) 역할을 하며, 거친 형태지만 전역 조명(Global Illumination)의 근사치로 볼 수도 있다. 이 점이 PBR(물리 기반 렌더링)에서 중요한 요소가 되는데, 환경의 조명을 반영할 경우 객체가 훨씬 더 물리적으로 정확하게 보이기 때문이다.

PBR 시스템에 IBL을 도입하기 위해, 다시 한번 반사 방정식을 살펴보자:
4. Archive/WhiteEngine/PBR/IBL/Attachments/Pasted image 20250315132534.png
앞서 설명했듯이, 우리의 주된 목표는 반구 Ω 위의 모든 입사광 방향 ωi에 대한 적분을 푸는 것이다. 이전 장에서는 기여하는 특정한 몇 개의 ωi 방향을 사전에 알고 있었기 때문에, 적분을 쉽게 해결할 수 있었다. 하지만 이번에는 주변 환경의 모든 입사광 방향 ωi가 일정량의 복사휘도를 가질 수 있어, 적분을 해결하는 것이 훨씬 더 복잡해진다. 따라서, 적분을 해결하기 위해 두 가지 주요 요구 사항이 필요하다:

  1. 주어진 입사광 방향 ωi에서 씬(Scene)의 복사휘도를 가져오는 방법이 필요하다.
  2. 적분을 빠르게, 실시간으로 해결해야 한다.

첫 번째 요구 사항은 비교적 쉽다. 앞서 언급했듯이, 환경 또는 씬의 복사 조도를 표현하는 한 가지 방법은 (처리된) 환경 큐브맵을 사용하는 것이다. 이런 큐브맵을 사용하면, 큐브맵의 각 텍셀을 개별적인 발광 광원으로 시각화할 수 있다. 따라서, 특정 방향 벡터 ωi로 이 큐브맵을 샘플링하면 해당 방향에서 씬의 복사휘도를 얻을 수 있다.

따라서, 주어진 방향 벡터 ωi​에서 씬의 복사휘도를 가져오는 과정은 단순히 다음과 같이 수행된다:

vec3 radiance = texture(_cubemapEnvironment, w_i).rgb;

여전히 적분을 해결하려면 환경 맵을 단일 방향이 아니라 반구 Ω 상의 모든 가능한 방향 ωi​에서 샘플링해야 한다. 하지만 이는 각 프래그먼트 셰이더 호출마다 수행하기에는 너무 비용이 크다. 따라서, 적분을 보다 효율적으로 해결하기 위해 대부분의 연산을 사전 처리 또는 사전 계산할 필요가 있다. 이를 위해 반사 방정식을 좀 더 깊이 살펴보자:
4. Archive/WhiteEngine/PBR/IBL/Attachments/Pasted image 20250315132831.png
반사 방정식을 자세히 살펴보면, BRDF의 난반사(kd) 및 반사광(ks​) 항이 서로 독립적임을 알 수 있으며, 이를 통해 적분을 두 개의 부분으로 나눌 수 있다:
4. Archive/WhiteEngine/PBR/IBL/Attachments/Pasted image 20250315132900.png
적분을 두 부분으로 나누면 난반사 항과 반사광 항을 개별적으로 다룰 수 있으며, 이번 장에서는 난반사 적분에 집중할 것이다.

난반사 적분을 더 자세히 살펴보면, 람버트 난반사 항(kdc/π)은 적분 변수와 관계없이 일정한 상수(색상 c, 굴절률 kd​, π)이므로, 이를 적분 바깥으로 이동할 수 있다:
4. Archive/WhiteEngine/PBR/IBL/Attachments/Pasted image 20250315132914.png
이제 적분이 오직 입사 방향 ωi에만 의존하게 되었으며(단, ppp가 환경 맵의 중심에 위치한다고 가정), 이를 활용하여 사전 계산된 새로운 큐브맵을 생성할 수 있다. 이 큐브맵은 각 샘플 방향(텍셀) ωo​에 대해 난반사 적분 결과를 저장한다.

이를 수행하는 방법이 컨볼루션(Convolution)이다. 컨볼루션은 특정 데이터셋의 각 항목에 대해, 전체 데이터셋을 고려한 연산을 적용하는 방식이다. 여기서 데이터셋은 씬의 복사휘도 또는 환경 맵이다. 따라서, 큐브맵의 각 샘플 방향에 대해 반구 Ω 내의 모든 샘플 방향을 고려하여 적분을 해결한다.

환경 맵을 컨볼루션하기 위해, 각 출력 방향 ωo에 대한 적분을 해결해야 한다. 이를 위해 반구 Ω 내의 다수의 입사 방향 ωi를 이산적으로 샘플링한 후, 해당 방향의 복사휘도를 평균화하는 방식을 사용한다. 이때, 샘플링할 반구는 우리가 컨볼루션하려는 출력 방향 ωo​을 향하도록 정렬된다.
4. Archive/WhiteEngine/PBR/IBL/Attachments/Pasted image 20250315132922.png
이렇게 사전 계산된 큐브맵은 각 샘플 방향 ωo\omega_oωo​에 대해 적분 결과를 저장하며, 이는 장면 내 모든 간접 난반사가 어떤 표면에 닿아 특정 방향 ωo\omega_oωo​을 따라 정렬된 경우의 사전 계산된 합으로 볼 수 있다.

이러한 큐브맵을 복사조도 맵(Irradiance Map) 이라고 한다. 이는 컨볼루션된 큐브맵이 특정 방향 ωo\omega_oωo​에서 장면의 (사전 계산된) 복사조도를 직접 샘플링할 수 있도록 해주기 때문이다.

방사휘도 방정식(Radiance Equation)은 위치 ppp 에도 의존하며, 우리는 지금까지 ppp 를 복사조도 맵(Irradiance Map)의 중심에 있다고 가정했다. 이는 모든 간접 확산광(Diffuse Indirect Light)이 단일 환경 맵에서 온다고 가정하는 것이므로, 현실감을 깨트릴 수 있다(특히 실내 환경에서). 렌더링 엔진은 이 문제를 해결하기 위해 장면 곳곳에 반사 프로브(Reflection Probes) 를 배치하여, 각 프로브가 주변 환경에 대한 고유한 복사조도 맵을 계산하도록 한다. 이렇게 하면 특정 위치 ppp 에서의 복사조도(및 방사휘도)는 가장 가까운 반사 프로브들 간의 보간된 값이 된다. 하지만 현재는 환경 맵을 항상 중심에서 샘플링한다고 가정한다.

아래는 큐브맵 환경 맵과 그로부터 생성된 복사조도 맵(Irradiance Map)의 예제이다(출처: Wave Engine). 이 복사조도 맵은 각 방향 wo 에 대해 장면의 방사휘도를 평균 내어 생성된다.
4. Archive/WhiteEngine/PBR/IBL/Attachments/Pasted image 20250317102733.png
각 큐브맵 텍셀에 wo​ 방향으로 컨볼루션된 결과를 저장함으로써, 복사조도 맵은 환경의 평균적인 색상이나 조명 상태를 나타내게 된다. 이 환경 맵에서 특정 방향을 샘플링하면 해당 방향에서의 장면 복사조도를 얻을 수 있다.

PBR and HDR

이전 장에서 간략히 언급했듯이, PBR 파이프라인에서 장면의 고동적 범위(HDR)를 고려하는 것은 매우 중요하다. PBR은 대부분의 입력을 실제 물리적 특성과 측정값에 기반으로 하기 때문에, 입사광의 값이 물리적 실측치와 가깝도록 설정하는 것이 합리적이다. 각 광원의 방사 플럭스를 추정하거나 직접적인 물리적 값을 사용할 때, 단순한 전구와 태양의 차이는 그 어떤 방식으로든 상당하다. HDR 렌더링 환경에서 작업하지 않으면 각 광원의 상대적인 강도를 정확히 지정하는 것이 불가능하다.

즉, PBR과 HDR은 서로 밀접한 관계가 있다. 하지만 이것이 이미지 기반 조명(IBL)과는 어떻게 연결될까? 이전 장에서 PBR을 HDR 환경에서 쉽게 적용할 수 있음을 확인했다. 하지만 이미지 기반 조명의 경우 환경의 간접 조명 강도를 환경 큐브맵의 색상 값에 의존하므로, 이 조명의 고동적 범위를 환경 맵에 저장하는 방법이 필요하다.

지금까지 큐브맵(예: 스카이박스)으로 사용한 환경 맵은 저동적 범위(LDR)로 되어 있다. 각 면 이미지를 직접 가져와 색상 값을 0.0에서 1.0 범위로 사용하고 그대로 처리했다. 이는 시각적 출력에는 문제없을 수 있지만, 물리적 입력 매개변수로 사용할 때는 적절하지 않다.
4. Archive/WhiteEngine/PBR/IBL/Attachments/Pasted image 20250317103516.png
이것은 우리가 예상했던 것과 다소 다를 수 있다. 이미지가 왜곡되어 있으며, 이전에 본 환경 맵의 6개 개별 큐브맵 면을 보여주지 않기 때문이다.

이 환경 맵은 구 형태에서 평면으로 투영된 것으로, 하나의 이미지로 환경을 보다 쉽게 저장할 수 있도록 만든 **등각 사각형 맵(equirectangular map)**이다. 하지만 이 방식에는 약간의 단점이 있다. 대부분의 시각적 해상도가 수평 방향에 집중되어 있으며, 상하 방향에서는 상대적으로 적은 정보가 유지된다.

그러나 대부분의 렌더러에서 중요한 조명과 환경 정보는 수평 시야 방향에 집중되는 경우가 많기 때문에, 이러한 절충은 일반적으로 실용적인 선택이다.

HDR 및 stb_image.h

Radiance HDR 이미지를 직접 로드하려면 파일 형식에 대한 어느 정도의 지식이 필요하다. 이는 그렇게 어렵지는 않지만 다소 번거로울 수 있다.

다행히도, stb_image.h라는 인기 있는 단일 헤더 라이브러리가 Radiance HDR 이미지를 직접 부동 소수점(float) 배열로 로드하는 기능을 제공한다. 이는 우리가 필요로 하는 기능과 완벽하게 맞아떨어진다.

stb_image를 프로젝트에 추가하면, HDR 이미지를 로드하는 작업이 다음과 같이 간단해진다.

#include "stb_image.h"
[...]

stbi_set_flip_vertically_on_load(true);
int width, height, nrComponents;
float *data = stbi_loadf("newport_loft.hdr", &width, &height, &nrComponents, 0);
unsigned int hdrTexture;
if (data)
{
    glGenTextures(1, &hdrTexture);
    glBindTexture(GL_TEXTURE_2D, hdrTexture);
    glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB16F, width, height, 0, GL_RGB, GL_FLOAT, data); 

    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

    stbi_image_free(data);
}
else
{
    std::cout << "Failed to load HDR image." << std::endl;
}

stb_image.h는 기본적으로 HDR 값을 채널당 32비트, 색상당 3채널의 부동소수점 값 목록으로 자동 매핑합니다. 이는 equirectangular HDR 환경 맵을 2D 부동소수점 텍스처에 저장하는 데 필요한 모든 것입니다.

From Equirectangular to Cubemap

직접 환경 조회에 equirectangular 맵을 사용할 수도 있지만, 이러한 연산은 상대적으로 비용이 많이 들 수 있습니다. 이 경우 직접적인 큐브맵 샘플링이 더 성능이 좋습니다. 따라서 이번 장에서는 먼저 equirectangular 이미지를 큐브맵으로 변환하여 추가 처리를 진행할 것입니다. 이 과정에서 equirectangular 맵을 3D 환경 맵처럼 샘플링하는 방법도 보여드리며, 원하는 해결 방법을 자유롭게 선택할 수 있습니다.

equirectangular 이미지를 큐브맵으로 변환하려면 (단위) 큐브를 렌더링하고 equirectangular 맵을 큐브의 모든 면에 내부에서 투사하여 큐브의 각 면에 대해 6개의 이미지를 큐브맵 면으로 찍어야 합니다. 이 큐브의 버텍스 셰이더는 큐브를 그대로 렌더링하고 큐브의 로컬 위치를 3D 샘플 벡터로서 프래그먼트 셰이더에 전달합니다:

#version 330 core
layout (location = 0) in vec3 aPos;

out vec3 localPos;

uniform mat4 projection;
uniform mat4 view;

void main()
{
    localPos = aPos;  
    gl_Position =  projection * view * vec4(localPos, 1.0);
}

프래그먼트 셰이더에서는 큐브의 각 부분을 equirectangular 맵을 큐브의 각 면에 깔끔하게 접은 것처럼 색칠합니다. 이를 위해, 큐브의 로컬 위치에서 보간된 샘플 방향을 취한 후, 이 방향 벡터와 일부 삼각법 (구면 좌표에서 직교 좌표로의 변환)을 사용하여 equirectangular 맵을 큐브맵처럼 샘플링합니다. 그 후 결과를 큐브 면의 프래그먼트에 직접 저장하면 됩니다:

#version 330 core
out vec4 FragColor;
in vec3 localPos;

uniform sampler2D equirectangularMap;

const vec2 invAtan = vec2(0.1591, 0.3183);
vec2 SampleSphericalMap(vec3 v)
{
    vec2 uv = vec2(atan(v.z, v.x), asin(v.y));
    uv *= invAtan;
    uv += 0.5;
    return uv;
}

void main()
{		
    vec2 uv = SampleSphericalMap(normalize(localPos)); // make sure to normalize localPos
    vec3 color = texture(equirectangularMap, uv).rgb;
    
    FragColor = vec4(color, 1.0);
}

HDR equirectangular 맵을 이용해 씬 중앙에 큐브를 렌더링하면, 다음과 같은 결과를 얻을 수 있습니다:
4. Archive/WhiteEngine/PBR/IBL/Attachments/Pasted image 20250317104234.png
이것은 우리가 equirectangular 이미지를 큐브 형태로 효과적으로 매핑했음을 보여주지만, 아직 원본 HDR 이미지를 큐브맵 텍스처로 변환하는 데에는 도움이 되지 않습니다. 이를 달성하기 위해서는 동일한 큐브를 6번 렌더링하여 큐브의 각 개별 면을 바라보며, 그 시각적 결과를 프레임버퍼 객체로 기록해야 합니다:

unsigned int captureFBO, captureRBO;
glGenFramebuffers(1, &captureFBO);
glGenRenderbuffers(1, &captureRBO);

glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
glBindRenderbuffer(GL_RENDERBUFFER, captureRBO);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, 512, 512);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_ATTACHMENT, GL_RENDERBUFFER, captureRBO);

물론, 그런 다음 각 6개의 면에 대해 메모리를 미리 할당하여 해당 큐브맵 색상 텍스처를 생성합니다:

unsigned int envCubemap;
glGenTextures(1, &envCubemap);
glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap);
for (unsigned int i = 0; i < 6; ++i)
{
    // 각 면을 16비트 부동 소수점 값으로 저장합니다.
    glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB16F, 
                 512, 512, 0, GL_RGB, GL_FLOAT, nullptr);
}
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

그런 다음 남은 작업은 equirectangular 2D 텍스처를 큐브맵의 면에 캡처하는 것입니다.

프레임버퍼와 포인트 그림자 장에서 이미 논의된 코드 세부 사항을 다시 설명하지는 않겠지만, 기본적으로 6개의 다른 뷰 행렬을 설정하고(큐브의 각 면을 향하게), 90도 시야각(fov)을 가진 투영 행렬을 설정하여 전체 면을 캡처하고, 큐브를 6번 렌더링하여 그 결과를 부동 소수점 프레임버퍼에 저장하는 작업입니다.

glm::mat4 captureProjection = glm::perspectiveradians(90.0f), 1.0f, 0.1f, 10.0f;
glm::mat4 captureViews[] = 
{
   glm::lookAtvec3(0.0f, 0.0f, 0.0f), glm::vec3( 1.0f,  0.0f,  0.0f), glm::vec3(0.0f, -1.0f,  0.0f),
   glm::lookAtvec3(0.0f, 0.0f, 0.0f), glm::vec3(-1.0f,  0.0f,  0.0f), glm::vec3(0.0f, -1.0f,  0.0f),
   glm::lookAtvec3(0.0f, 0.0f, 0.0f), glm::vec3( 0.0f,  1.0f,  0.0f), glm::vec3(0.0f,  0.0f,  1.0f),
   glm::lookAtvec3(0.0f, 0.0f, 0.0f), glm::vec3( 0.0f, -1.0f,  0.0f), glm::vec3(0.0f,  0.0f, -1.0f),
   glm::lookAtvec3(0.0f, 0.0f, 0.0f), glm::vec3( 0.0f,  0.0f,  1.0f), glm::vec3(0.0f, -1.0f,  0.0f),
   glm::lookAtvec3(0.0f, 0.0f, 0.0f), glm::vec3( 0.0f,  0.0f, -1.0f), glm::vec3(0.0f, -1.0f,  0.0f)
};

// convert HDR equirectangular environment map to cubemap equivalent
equirectangularToCubemapShader.use();
equirectangularToCubemapShader.setInt("equirectangularMap", 0);
equirectangularToCubemapShader.setMat4("projection", captureProjection);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, hdrTexture);

glViewport(0, 0, 512, 512); // don't forget to configure the viewport to the capture dimensions.
glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
for (unsigned int i = 0; i < 6; ++i)
{
    equirectangularToCubemapShader.setMat4("view", captureViews[i]);
    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, 
                           GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, envCubemap, 0);
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    renderCube(); // renders a 1x1 cube
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);

우리는 프레임버퍼의 색상 첨부 장치를 가져와 큐브맵의 각 면에 대해 텍스처 대상을 전환하며, 씬을 큐브맵의 면 중 하나로 직접 렌더링합니다. 이 루틴이 완료되면 (한 번만 수행하면 됨), envCubemap은 원본 HDR 이미지의 큐브맵 버전이 됩니다.

이제 큐브맵을 테스트하기 위해 큐브맵을 우리 주위에 표시할 간단한 스카이박스 셰이더를 작성해 보겠습니다:

#version 330 core
layout (location = 0) in vec3 aPos;

uniform mat4 projection;
uniform mat4 view;

out vec3 localPos;

void main()
{
    localPos = aPos;

    mat4 rotView = mat4(mat3(view)); // remove translation from the view matrix
    vec4 clipPos = projection * rotView * vec4(localPos, 1.0);

    gl_Position = clipPos.xyww;
}

여기서 xyww 트릭을 사용하여 렌더링된 큐브의 깊이 값이 항상 1.0, 즉 최대 깊이 값에 해당하도록 합니다. 이 방식은 큐브맵 장에서 설명한 대로 큐브의 각 면에서 깊이 값이 최대로 설정되도록 합니다. 또한 깊이 비교 함수는 GL_LEQUAL로 설정해야 합니다:

glDepthFunc(GL_LEQUAL);

그런 다음, 프래그먼트 셰이더는 큐브의 지역 프래그먼트 위치를 사용하여 큐브맵 환경 맵을 직접 샘플링합니다:

#version 330 core
out vec4 FragColor;

in vec3 localPos;
  
uniform samplerCube environmentMap;
  
void main()
{
    vec3 envColor = texture(environmentMap, localPos).rgb;
    
    envColor = envColor / (envColor + vec3(1.0));
    envColor = pow(envColor, vec3(1.0/2.2)); 
  
    FragColor = vec4(envColor, 1.0);
}

우리는 환경 맵을 샘플링할 때, 큐브의 인터폴레이션된 버텍스 위치를 사용하여 올바른 방향 벡터를 샘플링합니다. 카메라의 이동 성분은 무시되므로 이 셰이더를 큐브에 렌더링하면 움직이지 않는 배경으로 환경 맵이 나타납니다. 또한, 환경 맵의 HDR 값을 기본 LDR 프레임버퍼로 직접 출력하기 때문에 색상 값을 적절히 톤 매핑해야 합니다. 대부분의 HDR 맵은 기본적으로 선형 색상 공간에 있으므로 프레임버퍼에 기록하기 전에 감마 보정을 적용해야 합니다.

이제 이전에 렌더링된 구 위에 샘플링된 환경 맵을 렌더링하면 다음과 같은 결과를 얻을 수 있습니다:
4. Archive/WhiteEngine/PBR/IBL/Attachments/Pasted image 20250317104843.png
정말 많은 설정을 거쳐 여기까지 왔네요! 이제 우리는 HDR 환경 맵을 읽고, 그것을 equirectangular 맵핑에서 큐브맵으로 변환한 후, HDR 큐브맵을 장면의 스카이박스로 렌더링하는 데 성공했습니다. 또한 큐브맵의 6개 면에 렌더링할 작은 시스템을 설정했으며, 이는 환경 맵을 컨볼루션할 때 다시 필요하게 됩니다. 전체 변환 프로세스의 소스 코드는 여기에서 찾을 수 있습니다.

Cubemap convolution

앞서 장에서 설명한 대로, 우리의 주요 목표는 씬의 방사 휘도 정보를 큐브맵 환경 맵 형태로 제공받은 간접 확산 조명을 위한 적분을 해결하는 것입니다. 특정 방향 wi에서 HDR 환경 맵을 샘플링함으로써 씬의 방사 휘도 L(p, wi)를 얻을 수 있다는 것을 알고 있습니다. 이 적분을 해결하려면 각 프래그먼트에 대해 반구 Ω 내의 가능한 모든 방향에서 씬의 방사 휘도를 샘플링해야 합니다.

그러나 반구 Ω 내에서 모든 가능한 방향에서 환경의 조명을 샘플링하는 것은 계산적으로 불가능합니다. 가능한 방향의 수는 이론적으로 무한하기 때문입니다. 그러나 우리는 유한한 수의 방향 또는 샘플을 취하여 방사 휘도의 근사값을 얻을 수 있습니다. 샘플은 반구 내에서 고르게 간격을 두거나 무작위로 선택될 수 있습니다. 이렇게 하면 적분 ∫을 효과적으로 이산적으로 해결할 수 있습니다.

그럼에도 불구하고 이는 실시간으로 모든 프래그먼트에 대해 수행하기에는 여전히 너무 비용이 많이 듭니다. 적당한 결과를 얻으려면 샘플의 수가 상당히 커야 하기 때문입니다. 따라서 우리는 이를 미리 계산하고자 합니다. 반구의 방향은 우리가 방사 휘도를 캡처할 위치를 결정하므로, 우리는 모든 나가는 방향 wo를 중심으로 반구 방향에 대해 방사 휘도를 미리 계산할 수 있습니다:

4. Archive/WhiteEngine/PBR/IBL/Attachments/Pasted image 20250317112436.png

조명 패스에서 방향 벡터 wi가 주어지면, 우리는 미리 계산된 방사 휘도 맵을 샘플링하여 방향 wi에서의 전체 확산 방사 휘도를 가져올 수 있습니다. 프래그먼트 표면에서 간접 확산(방사) 빛의 양을 결정하기 위해, 우리는 표면 법선 주위로 정렬된 반구에서 전체 방사 휘도를 가져옵니다. 씬의 방사 휘도를 얻는 것은 간단히:

vec3 irradiance = texture(irradianceMap, N).rgb;

이제 방사 휘도 맵을 생성하기 위해, 우리는 큐브맵으로 변환된 환경 조명을 컨볼루션해야 합니다. 각 프래그먼트에 대해 표면의 반구는 법선 벡터 N을 따라 정렬되므로, 큐브맵을 컨볼루션하는 것은 N 방향을 따라 정렬된 반구 Ω에서 각 방향 wi의 평균 방사 휘도를 계산하는 것과 같습니다.
4. Archive/WhiteEngine/PBR/IBL/Attachments/Pasted image 20250317112442.png
다행히도, 이번 장에서의 번거로운 설정이 헛되지 않아서 이제 변환된 큐브맵을 직접 사용하고, 이를 프래그먼트 셰이더에서 컨볼루션하여 새로운 큐브맵에 그 결과를 캡처할 수 있습니다. 이미 equirectangular 환경 맵을 큐브맵으로 변환하는 설정을 해두었으므로, 정확히 같은 방법을 사용하되 다른 프래그먼트 셰이더를 사용하는 방식으로 진행할 수 있습니다:

#version 330 core
out vec4 FragColor;
in vec3 localPos;

uniform samplerCube environmentMap;

const float PI = 3.14159265359;

void main()
{		
    // 샘플링 방향은 반구의 방향과 일치
    vec3 normal = normalize(localPos);
  
    vec3 irradiance = vec3(0.0);
  
    [...] // 컨볼루션 코드
  
    FragColor = vec4(irradiance, 1.0);
}

여기서 environmentMap은 equirectangular HDR 환경 맵에서 변환된 HDR 큐브맵입니다.

환경 맵을 컨볼루션하는 방법에는 여러 가지가 있지만, 이 장에서는 각 큐브맵 텍셀에 대해 반구 Ω를 기준으로 샘플 방향에 정렬된 일정 수의 샘플 벡터를 생성하고, 그 결과를 평균화하는 방법을 사용합니다. 이 일정 수의 샘플 벡터는 반구 내에서 균일하게 분포됩니다. 적분은 연속적인 함수이므로 고정된 수의 샘플 벡터로 함수의 값을 샘플링하면 근사값이 됩니다. 샘플 벡터가 많을수록 적분을 더 잘 근사할 수 있습니다.

반사 방정식의 적분 ∫는 고체 각도 dw와 관련이 있는데, 이는 다루기가 상당히 어렵습니다. 따라서 고체 각도 dw에 대해 적분하는 대신, 이를 대응되는 구면 좌표인 θ와 ϕ로 적분합니다.
4. Archive/WhiteEngine/PBR/IBL/Attachments/Pasted image 20250317113830.png

우리는 극각 ϕ 각도를 사용하여 반구의 링을 따라 0과 2π 사이에서 샘플링하고, inclination zenith θ 각도를 사용하여 0과 1/2 * π 사이에서 반구의 점차 증가하는 링을 샘플링합니다. 이렇게 하면 갱신된 반사 적분을 얻을 수 있습니다:
4. Archive/WhiteEngine/PBR/IBL/Attachments/Pasted image 20250317113942.png
적분을 해결하려면 반구 Ω 내에서 고정된 수의 이산 샘플을 취하고 그 결과를 평균화해야 합니다. 이는 Riemann 합을 기반으로 하는 다음과 같은 이산 버전으로 변환됩니다:
4. Archive/WhiteEngine/PBR/IBL/Attachments/Pasted image 20250317113953.png
우리는 두 구면 좌표를 이산적으로 샘플링하므로 각 샘플은 반구의 영역을 근사하거나 평균화합니다. 위 그림에서와 같이 반구의 이산 샘플 영역은 zenith 각도 θ가 커질수록 작아집니다. 이는 샘플 영역이 중심 상단으로 수렴하기 때문입니다. 작은 영역을 보정하기 위해, 우리는 그 기여도를 sinθ로 스케일링하여 보정합니다.

적분의 구면 좌표에 따른 반구 샘플링을 이와 같은 프래그먼트 코드로 변환할 수 있습니다:

vec3 irradiance = vec3(0.0);  

vec3 up    = vec3(0.0, 1.0, 0.0);
vec3 right = normalize(cross(up, normal));
up         = normalize(cross(normal, right));

float sampleDelta = 0.025;
float nrSamples = 0.0; 
for(float phi = 0.0; phi < 2.0 * PI; phi += sampleDelta)
{
    for(float theta = 0.0; theta < 0.5 * PI; theta += sampleDelta)
    {
        // 구면 좌표에서 직교 좌표로 변환 (접선 공간에서)
        vec3 tangentSample = vec3(sin(theta) * cos(phi),  sin(theta) * sin(phi), cos(theta));
        // 접선 공간에서 월드 공간으로 변환
        vec3 sampleVec = tangentSample.x * right + tangentSample.y * up + tangentSample.z * N; 

        irradiance += texture(environmentMap, sampleVec).rgb * cos(theta) * sin(theta);
        nrSamples++;
    }
}
irradiance = PI * irradiance * (1.0 / float(nrSamples));

우리는 고정된 샘플 델타 값 sampleDelta를 지정하여 반구를 순회합니다. 샘플 델타를 줄이거나 늘리면 정확도가 각각 증가하거나 감소합니다.

두 루프 안에서는 두 구면 좌표를 취하여 이를 3D 직교 샘플 벡터로 변환하고, 샘플을 접선 공간에서 월드 공간으로 변환하여 샘플 벡터로 HDR 환경 맵을 샘플링합니다. 각 샘플 결과를 irradiance에 더하고, 마지막에는 전체 샘플 수로 나누어 평균 샘플링된 조도를 얻습니다. 색상 샘플 값을 cos(theta)로 스케일링하는 이유는 더 큰 각도에서 빛이 약해지기 때문이며, sin(theta)로 스케일링하는 이유는 높은 반구 영역에서 작은 샘플 영역을 보정하기 위함입니다.

이제 남은 작업은 OpenGL 렌더링 코드를 설정하여 이전에 캡처한 envCubemap을 컨볼루션하는 것입니다. 먼저, 조도 큐브맵을 생성합니다 (다시 말하지만, 렌더 루프 전에 한 번만 수행하면 됩니다):

unsigned int irradianceMap;
glGenTextures(1, &irradianceMap);
glBindTexture(GL_TEXTURE_CUBE_MAP, irradianceMap);
for (unsigned int i = 0; i < 6; ++i)
{
    glTexImage2D(GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, 0, GL_RGB16F, 32, 32, 0, 
                 GL_RGB, GL_FLOAT, nullptr);
}
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_WRAP_R, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_CUBE_MAP, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

조도 맵은 주변의 모든 방사선을 균일하게 평균화하므로 고주파 세부 사항이 많지 않아서 낮은 해상도(32x32)로 저장할 수 있고, OpenGL의 선형 필터링이 대부분의 작업을 처리할 수 있습니다. 그런 다음, 캡처 프레임 버퍼를 새로운 해상도로 다시 크기를 조정합니다:

glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
glBindRenderbuffer(GL_RENDERBUFFER, captureRBO);
glRenderbufferStorage(GL_RENDERBUFFER, GL_DEPTH_COMPONENT24, 32, 32);  

컨볼루션 셰이더를 사용하여 환경 맵을 캡처한 큐브맵처럼 렌더링합니다:

irradianceShader.use();
irradianceShader.setInt("environmentMap", 0);
irradianceShader.setMat4("projection", captureProjection);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_CUBE_MAP, envCubemap);

glViewport(0, 0, 32, 32); // 캡처 해상도에 맞게 뷰포트를 설정하는 것을 잊지 마세요.
glBindFramebuffer(GL_FRAMEBUFFER, captureFBO);
for (unsigned int i = 0; i < 6; ++i)
{
    irradianceShader.setMat4("view", captureViews[i]);
    glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, 
                           GL_TEXTURE_CUBE_MAP_POSITIVE_X + i, irradianceMap, 0);
    glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

    renderCube();
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);  

이제 이 루틴을 마친 후, 우리는 미리 계산된 조도 맵을 직접 사용할 수 있게 되며, 이를 통해 확산 이미지 기반 조명을 구현할 수 있습니다. 환경 맵을 컨볼루션한 결과를 확인하려면, 환경 맵을 조도 맵으로 교체하여 스카이박스의 환경 샘플러로 사용하면 됩니다:
4. Archive/WhiteEngine/PBR/IBL/Attachments/Pasted image 20250317115824.png
환경 맵이 심하게 흐릿한 버전처럼 보인다면, 환경 맵을 성공적으로 컨볼루션한 것입니다.

PBR and indirect irradiance lighting

복사조도 맵(irradiance map)은 반사 적분(reflectance integral)의 난반사(diffuse) 부분을 나타내며, 이는 주변의 모든 간접광으로부터 누적된 값이다. 이 조명은 직접적인 광원이 아니라 주변 환경에서 오는 것이므로, 난반사 및 거울반사(specular) 간접 조명을 모두 주변광(ambient lighting)으로 처리하여 이전에 설정한 상수 항을 대체한다.

우선, 미리 계산된 복사조도 맵을 큐브 샘플러로 추가해야 한다.

uniform samplerCube irradianceMap;

복사조도 맵에는 장면의 모든 간접 난반사광이 포함되어 있으므로, 프래그먼트에 영향을 주는 복사조도를 가져오는 것은 단순히 표면 법선(normal)을 이용한 한 번의 텍스처 샘플링으로 해결할 수 있다.

// vec3 ambient = vec3(0.03);
vec3 ambient = texture(irradianceMap, N).rgb;

그러나 간접 조명에는 난반사와 거울반사 성분이 모두 포함되므로(반사 방정식의 분리된 형태에서 보았듯이), 난반사 부분을 적절히 가중해야 한다. 이전 장에서 했던 것처럼, 표면의 간접 반사 비율을 결정하기 위해 프레넬 방정식을 사용하고, 이를 통해 굴절(또는 난반사) 비율을 유도한다.

vec3 kS = fresnelSchlick(max(dot(N, V), 0.0), F0);
vec3 kD = 1.0 - kS;
vec3 irradiance = texture(irradianceMap, N).rgb;
vec3 diffuse    = irradiance * albedo;
vec3 ambient    = (kD * diffuse) * ao; 

주변광은 법선 N을 기준으로 하는 반구(hemisphere) 내의 모든 방향에서 오므로, 프레넬 응답(Fresnel response)을 결정할 단일한 하프벡터(halfway vector)가 존재하지 않는다.
그래도 프레넬 효과를 시뮬레이션하기 위해, 우리는 법선과 뷰 벡터 사이의 각도를 이용하여 프레넬 값을 계산한다.

그러나 이전에는 표면 거칠기에 영향을 받는 미세 표면의 하프벡터를 프레넬 방정식의 입력으로 사용했다. 현재 거칠기를 고려하지 않으므로, 표면의 반사율이 항상 상대적으로 높게 나타난다.
간접광도 직접광과 동일한 성질을 따르므로, 거친 표면에서는 가장자리(edge)에서 반사 강도가 낮아져야 한다.
이 때문에 거친 비금속 표면에서의 간접 프레넬 반사 강도가 부자연스럽게 보인다(과장된 예시).
4. Archive/WhiteEngine/PBR/IBL/Attachments/Pasted image 20250320191701.png

이 문제를 완화하기 위해, Sébastien Lagarde가 설명한 것처럼 프레넬-슐리크(Fresnel-Schlick) 방정식에 거칠기(roughness) 항을 추가할 수 있다.

vec3 fresnelSchlickRoughness(float cosTheta, vec3 F0, float roughness)
{
    return F0 + (max(vec3(1.0 - roughness), F0) - F0) * pow(clamp(1.0 - cosTheta, 0.0, 1.0), 5.0);
}   

프레넬 응답을 계산할 때 표면의 거칠기를 고려하면, 주변광 계산은 다음과 같이 정리된다.

vec3 kS = fresnelSchlickRoughness(max(dot(N, V), 0.0), F0, roughness); 
vec3 kD = 1.0 - kS;
vec3 irradiance = texture(irradianceMap, N).rgb;
vec3 diffuse    = irradiance * albedo;
vec3 ambient    = (kD * diffuse) * ao; 

보다시피, 실제 이미지 기반 조명(Image-Based Lighting, IBL) 계산 자체는 매우 간단하며, 단 한 번의 큐브맵 텍스처 조회만 필요하다. 대부분의 작업은 복사조도 맵을 미리 계산하거나 컨볼루션(convolution)하는 과정에서 이루어진다.

만약 PBR 조명 챕터에서 다뤘던 초기 장면을 가져와 각 구(sphere)에 대해 수직으로 증가하는 금속성(metallic)과 수평으로 증가하는 거칠기 값을 적용한 뒤, 난반사 이미지 기반 조명을 추가하면 다음과 같은 모습이 된다.

4. Archive/WhiteEngine/PBR/IBL/Attachments/Pasted image 20250320191928.png

여전히 약간 어색한 느낌이 드는데, 이는 금속성(metallic)이 높은 구(sphere)들이 금속 표면답게 보이려면 적절한 반사가 필요하기 때문이다. 금속 표면은 난반사(diffuse) 광을 반사하지 않으므로, 현재로서는 오직 점광원(point light source)에서 오는 미미한 반사만 존재한다.

그럼에도 불구하고, 환경 맵을 바꿔가며 살펴보면 구들이 주변 환경과 더 잘 어우러진다는 것을 알 수 있다. 이는 표면의 응답이 환경의 주변광(ambient lighting)에 맞춰 적절하게 반응하기 때문이다.

논의한 내용을 포함한 전체 소스 코드는 여기에서 확인할 수 있다. 다음 장에서는 반사 적분(reflectance integral)의 간접적인 경면 반사(indirect specular) 부분을 추가할 것이며, 이를 통해 PBR의 진정한 강력함을 확인할 수 있을 것이다.

Further reading